Anthony Chao

用 Rails 實作 RAG 初體驗

#AI#Rails

我第一次接觸到 RAG 是在 Hello World Dev Conference 的 workshop 中。雖然 workshop 只是簡單地帶我們實作從一個 PDF 檔案中搜尋內容,但這次體驗讓我了解到目前市場上常見解決方案背後的原理。為了進一步了解 RAG 的運作方式,我決定自己實作一個小小的 side project 試試看。

RAG 是什麼?

RAG,即 Retrieval-Augmented Generation,是一種結合檢索機制與生成模型的 AI 技術。傳統語言模型雖然功能強大,但其內部知識庫是固定的,可能會隨時間變得過時或不完整。RAG 透過兩個步驟解決這個問題:

  • 檢索:從外部知識來源(例如資料庫或文件庫)中找到相關且最新的資訊。
  • 生成:利用檢索到的資訊生成答案,確保回答準確且與當前情境相關。

這種檢索與生成的結合,使 AI 系統能產出更具時效性的答案,非常適合需要最新資訊的應用場景,例如客服支援、新聞更新或學術研究。目前,檢索步驟通常採用向量搜尋(vector search)技術來尋找相關資料。

向量搜尋是什麼?

向量資料庫使用向量表示每個物件,以保留資料的語義關係。將語意轉換成向量的過程稱為 embedding,而每個向量對應多維空間中的一個點。例如,在下圖中,香蕉的向量位置會比較靠近蘋果,而雞跟貓的向量位置會比較相近:

image

至於這些向量所在的多維空間到底有多少維度,目前最普遍被使用的 OpenAI text-embedding-3-small 模型提供 1536 維度,而進階的 text-embedding-3-large 模型則提供 3072 維度。這些高維度特性,能帶來更精確的結果。

Steps

RAG 通常由以下的步驟構成:

  • 載入:使用工具(如 langchain 的 Document loader)載入資料。
  • 分割:使用 Text Splitter 將大型文件分成小片段,以利於索引及傳入模型處理,因為大型片段難以搜尋且不適合模型的 context window。
  • 儲存:需要一個儲存和索引這些片段的地方,以便日後進行搜尋。這通常使用向量資料庫和 embedding 模型來完成。
  • 檢索:根據使用者輸入,使用 Retriever 從儲存中獲取相關片段。
  • 生成:LLM 利用問題與檢索結果生成答案。

在這次的 RAG demo 中,我使用 我的部落格 的文章作為資料來源,並採用 Rails 作為網頁框架,向量資料庫則使用 Qdrant 提供的免費雲端方案。

Ruby Code

載入

由於資料來源是 markdown 檔案,載入部分直接使用 Ruby 的 File.read(file_path) 方法。若使用更成熟的框架(如 Langchain),則可支援載入多種來源(如 PDF 或網頁)。

分割

一般來說,分割會將檔案切成多個小片段並儲存到向量資料庫,同時需維護檔案與資料庫間的關聯,方便日後更新。而為了簡化這個 side project,我只有在文章超過 token 限制時,將文章分段後使用 LLM 進行摘要,再將摘要儲存到向量資料庫中,如此一來我就不用使用另一個資料庫儲存相對應的關聯資訊。雖然這可能導致部分細節遺失,但對於這次 demo 已經足夠。

我使用的是 langchainrb 套件中的 Chunker::RecursiveText 來進行分段。順帶一提雖然 langchainrb 套件目前功能比 Python 的 Langchain 提供的功能陽春很多,但已能滿足我的大部分需求。

class AITextSummarizer
  CHUNK_SIZE = 4000
  CHUNK_OVERLAP_SIZE = 200
  SUMMARY_TOKENS = 1000 # Desired length of summary
  OPENAI_MODEL = "gpt-4o-mini"

  def initialize
    @client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
  end

  def summarize(text)
    chunks = split_into_chunks(text)
    summaries = chunks.map { |chunk| summarize_chunk(chunk.text) }
    combine_summaries(summaries)
  end

  private

  def split_into_chunks(text)
    Langchain::Chunker::RecursiveText.new(text, chunk_size: CHUNK_SIZE, chunk_overlap: CHUNK_OVERLAP_SIZE, separators: [ "\n" ]).chunks
  end

  def combine_summaries(summaries)
    if summaries.size == 1
      summaries.first
    else
      combined_summary = summaries.join("\n\n")
      summarize_summaries(combined_summary)
    end
  end

  def summarize_chunk(text)
    summarize_with_prompt(text, "請把下面這段文字用繁體中文總結到大約 100 字:")
  end

  def summarize_summaries(text)
    summarize_with_prompt(text, "請把下面這段文字用繁體中文總結,盡量不要遺漏太多細節:")
  end

  def summarize_with_prompt(text, prompt)
    response = @client.chat(
      parameters: {
        model: OPENAI_MODEL,
        messages: [
          { role: "system", content: "You are a helpful assistant that summarizes text." },
          { role: "user", content: "#{prompt}\n\n#{text}" }
        ],
        max_tokens: SUMMARY_TOKENS
      }
    )
    response.dig("choices", 0, "message", "content")
  end
end

儲存

儲存資料需要嵌入模型(embedding model)將資料轉換為向量,然後將結果存入向量資料庫。在我的實作中,我將文章內容、標題及網址等相關資訊一起存入向量資料庫的 payload(可以想像成 metadata),這樣最後 LLM 回答問題時就可以直接告訴我相關的網址跟文章標題。

閱讀下面的程式碼前有幾點需要先知道

  1. langchainrb 套件的 Langchain::Vectorsearch::Qdrant 會自動幫忙使用 llm 的 embedding model 轉成向量,所以只要提供使用的 llm API key 即可
  2. Qdrant 的專有名詞 a. collection 是用來儲存 points 的集合,可以想像成是 RDBMS 裡面的 table b. point 是在向量資料庫的最重要 entity,一個點包含了一個向量跟 payload,可以想像成是 RDBMS 裡面的 row
  3. 使用之後才發現 Qdrant 中 point 的 id 必須遵守幾種可能的格式(ref),我這裡使用文章的發布日期轉成 uuid 作為 point 的 id,如此一來同一篇文章更新的時候,同樣的發布日期就可以直接取代舊的 point
add_point(file_name: file_name, original_content: content, extracted_content: extracted_content)

def add_point(file_name:, original_content:, extracted_content:)
  Langchain::VectorSearchClient.new(collection_name: COLLECTION_NAME).add_point(
    content: extracted_content,
    id: uuid_for(original_content),
    payload: {
      title: title_for(original_content),
      url: url_for(file_name)
    }
  )
end

# 目前用建立時間作為 uuid 根據
def uuid_for(content)
  datetime_str = content.match(/^date:\s*(.*)/)[1]
  brief_datetime_str = DateTime.parse(datetime_str)
                                .strftime("%Y%m%d%H%M%S")

  sha1_hash = Digest::SHA1.hexdigest(brief_datetime_str)
  # to make sha1 match uuid format
  # https://qdrant.tech/documentation/concepts/points/
  "#{sha1_hash[0..7]}-#{sha1_hash[8..11]}-#{sha1_hash[12..15]}-#{sha1_hash[16..19]}-#{sha1_hash[20..31]}"
end

module Langchain
  class VectorSearchClient
    def initialize(collection_name:)
      # default openai model: text-embedding-3-small
      @qdrant = Langchain::Vectorsearch::Qdrant.new(
        url: ENV["QDRANT_URL"],
        api_key: ENV["QDRANT_API_KEY"],
        index_name: collection_name, # Note: collection name 近似於 RDBMS 的 table name,用來儲存 points
        llm: Langchain::LLM::OpenAI.new(api_key: ENV["OPENAI_API_KEY"])
      )
    end

    def add_point(content:, id:, payload: {})
      @qdrant.add_texts(
        texts: [ content ],
        ids: [ id ],
        payload: payload
      )
    end
  end
end

檢索 & 生成

langchainrb 套件的 Langchain::Vectorsearch::Qdrant 模組會自動使用 LLM 的 embedding model 把問題轉成向量並進行相似度搜尋。搜尋結果返回後,LLM 將根據問題和檢索資料生成答案。

以下程式碼中的 k 表示從相似度搜尋中返回的 point 數量,LLM 會以這些 point 的資料作為生成答案的基礎。

class QuestionsController < ApplicationController
  RELATED_POSTS_AMOUNT = 3

  def create
    @client = Langchain::VectorSearchClient.new(collection_name: "blog")
    @response = @client.ask(question: question_params, k: RELATED_POSTS_AMOUNT).completion
    respond_to do |format|
      format.turbo_stream
    end
  end
end

module Langchain
  class VectorSearchClient
    # ...
    def ask(question:, k:)
      @qdrant.ask(question: question, k: k)
    end
    # ...
  end
end

Demo

以上就是這個小專案中跟 RAG 有關的部分。

在 UI 實作方面,,我使用 Rails 搭配 Turbo 來作出簡單的問答頁面,成果長得像下面這樣:

問問題的頁面:

image

LLM 回答的結果:

image

從上面的圖片可以看到因為我有給他文章網址,因此他在回答的時候也能提供這部分的資訊。

心得

實作這個小專案的過程中,我才真正體會到開發一個 RAG 應用程式時需要注意的各種細節。例如,當一篇文章被更新時,如何有效地同步更新向量資料庫中的相關資料,以及針對長篇文章,有哪些分割方法可以使用,而哪些方法的效果大家實驗後覺得較為理想,這些都是值得研究的議題。

接下來,我計劃進一步學習 LangChain 的使用方式,透過實作幾個小應用來熟悉他。接著希望能深入了解實作細節和所使用的 prompt 等等,可能未來能更靈活地應用到其他專案中。

References

向量搜尋介紹 LangChain document